perf(vulkan): decode GEMV reads four elements per load, and the row lever is dead - #184
Merged
Conversation
…ever is dead
vt_matmul_vec is 89-90% of 27B decode GPU time and moves ~52 GB of bf16 weights
per token, so it was the largest single line item left. Two structural
differences against llama.cpp's mul_mat_vec (pin 237ad9b96) were the candidates.
ONE is worth about 1%, the OTHER is a measured LOSS on this device, and the win
is 7x smaller than the isolated sweep first said. All three of those are the
result, and the third is the most useful.
WHAT SHIPPED. VT_MM_PACK, a specialization constant selecting the LOAD WIDTH for
a 16-bit operand: one element per load (2 B, the previous behaviour), two through
the buffer's existing 32-bit view, or four through a new 64-bit view of the same
VkBuffer. Ported in intent from llama.cpp's data_b_v4 / dequantize4_2aligned
(vulkan-shaders/dequant_funcs.glsl:54-61, which reads bf16 weights as two 32-bit
words). Same bytes, same coalescing -- a 32-lane subgroup still covers 128 or 256
CONSECUTIVE bytes -- so this CANNOT reduce DRAM traffic. It reduces LOAD
INSTRUCTIONS, four to one. Default 2, degrading a width at a time when K or an
operand byte offset is not aligned to it, and declining for an f32 operand.
WHAT DID NOT WORK, AND ITS NUMBER. VT_MM_ROWS, llama.cpp's NUM_ROWS
(mul_mat_vec_base.glsl:90; ggml-vulkan.cpp:4705-4719 sets it per vendor via
rm_stdq), computing 2 or 4 output elements per workgroup so one loaded activation
feeds several weight rows. MEASURED 0.966x at 2 rows and 0.968x at 4, in every
pass and at every load width. It ships OFF. What it was supposed to buy was never
traffic -- the weights are read exactly once per token at any row count -- and the
activation re-reads it saves were L2 hits. Halving the workgroup count halves the
independent sequential read streams the memory controller sees while each
surviving workgroup interleaves rows K*2 bytes apart, which is the likely cost.
The axis is KEPT, off by default, because llama.cpp raises it exactly on the AMD
GCN and Intel parts this backend's portability case is aimed at, so deleting it
would mean rediscovering it on the VK-I board. It is bit-identical to rows=1 at
any value, verified by a full-output bit-hash and not only by argument.
MEASURED, ISOLATED SWEEP, AND IT OVERSTATED THE WIN BY 7x.
benchmarks/vulkan_gemv_ab.cpp runs the GEMV over the seven (k, n) shapes
Qwen3.6-27B actually dispatches; 9 arms x 4 rotated passes, each arm paired
against the rows=1/pack=0 baseline measured IN THE SAME PASS (the box drifted
15.5% peak-to-peak between passes, so an unpaired ranking would have been noise):
pack 0 pack 1 pack 2
rows 1 1.000x 1.025x 1.086x
rows 2 0.966x 1.017x 1.047x
rows 4 0.968x 1.014x 1.039x
MEASURED, REAL 27B DECODE, which is the number of record. Two-length GPU-timestamp
diff, output-len 36 minus output-len 4 over 32 decode tokens, so prefill and every
one-time cost cancels. Run twice, arm order reversed the second time:
per decoded token blk1 pack0 blk1 pack2 blk2 pack0 blk2 pack2
vt_matmul_vec ms 211.99 209.52 214.59 210.38
GB/s (52.1 GB/token) 245.8 248.7 242.8 247.6
% of the 273 GB/s roof 90.0% 91.1% 88.9% 90.7%
speedup 1.012x 1.020x
1.012x and 1.020x, not 1.086x. THE SWEEP WAS IN THE WRONG REGIME: it re-reads ONE
356 MB weight buffer 320 times, so its DRAM rows and TLB entries stay hot and
instruction issue becomes visible as a secondary constraint. Decode streams 50 GB
of distinct weights once per token, where DRAM is the whole story. The campaign
already has "a negative result is regime-dependent" on record from the GEMV
unroll; this is the same lesson running the other way, and an isolated harness
must be shown to reproduce the e2e operating point (this one does: 89.3% of roof
against the e2e diff's 90.0%) before its RANKING is believed.
END TO END. Order-alternated AB/BA, page cache dropped before every leg, two
blocks of 6 and 8 pairs, one binary with VT_VULKAN_GEMV_PACK as the only variable
(=0 reproduces the pre-row kernel bit for bit). Eight of the 28 legs landed in the
lm_head slow mode described below, 5 baseline and 3 pack=2, so arm-symmetric; they
are reported rather than dropped. Of the six pairs where BOTH legs were clean,
pack=2 wins 5, median paired ratio 1.0064x. Clean-leg medians 242.38 -> 240.54 ms,
decode 4.126 -> 4.157 tok/s against llama.cpp Vulkan's 4.35. The measured noise
floor on the clean legs is 0.88% peak-to-peak on the baseline arm (9 legs) and
1.92% on the other (11), so a 0.7% effect sits AT the edge of what an e2e wall
clock resolves here; the GPU-timestamp diff is the instrument that resolves it,
and the two agree in sign and magnitude.
SO: IS ANYTHING LEFT IN THE GEMV? ABOUT ONE PERCENT. The kernel was already at
90.0% of the GB10 bandwidth roof and is at 91.1%. llama.cpp Vulkan's 4.35 tok/s is
229.9 ms/token wall against our 240.5; the GEMV alone is 210 ms of that, so even a
PERFECT GEMV leaves ~20 ms and the rest of the gap is the ~30 ms/token of non-GEMV
cost. The binding constraint on this kernel is DRAM bandwidth on a 50 GB working
set streamed once per token, and it is 9% from the roof.
SIDE FINDING, WORTH MORE THAN THIS ROW'S 1%. The ~1.8x bimodal decode legs this
box produces, which the previous Vulkan row discarded as environmental, are
vt_matmul -- the lm_head, NN orientation, ONE call per token -- collapsing by
19.5x: 12.35 ms/token in the fast mode and 242.12 ms/token in the slow one, in the
same two-length diff, while vt_matmul_vec beside it moved 1.2%. At 2.54 GB of
lm_head weights that is 205 GB/s versus 10.5 GB/s. Mean TTFT is unchanged across
the modes (6.21 to 6.55 s over all 28 legs), so it is neither the load nor
prefill. Whatever selects that state is a bigger lever than every remaining
Vulkan kernel optimisation combined.
CORRECTNESS. The load width repartitions K across lanes, so it changes the
answer's low bits exactly as the tactic itself does and is gated on token
exactness, not on an NMSE bound. New case "vt_matmul_vec selects its load width
and row count from the shape" asserts the SPECIALIZATION VALUES the dispatch
built, not the numbers: every value of both axes computes the same dot product to
within this kernel's tier, so a tolerance test cannot see the optimisation at all
and would still pass if the predicate silently stopped selecting it. It pins the
shipped default and the whole degradation ladder (K = 2 mod 4 halves the width,
odd K falls to one element, an f32 activation declines), and carries numeric
checks including the LAST output row, where a wide-load or row-count mistake at
the end of a dispatch leaves every earlier row right. Scratch mutation: reverting
kGemvPackDefault to 0 turns it red (6 of 33 assertions), restoring it green.
GATES, all on GB10 unless noted. test_vulkan_backend 30/30 (2366 assertions) on
GB10 and 30/30 (1823) on llvmpipe -- one case and 37 assertions more than the
previous 29/1786 because of the new case. test_backend_cross_device 11/11 (132).
test_opt_paged_engine with VLLM_CPP_DEVICE=vulkan 6/6 prompts token-exact (96/96
tokens), 0 declines. gen-vulkan-spirv.py --check clean at pinned glslang 16.5.0.
All nine (rows, pack) arms verified numerically on llvmpipe. check-public-doc-
tables and check-env-doc still report the same three and twelve pre-existing
findings they report on 93852c2, none of them in this change.
FOLLOWING_AGENTS_PROTOCOL
Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: Claude-Code:claude-opus-5 [Claude Code]
rohitpaul
pushed a commit
to rohitpaul/vllm.cpp
that referenced
this pull request
Aug 9, 2026
Each of mudler#184, mudler#185 and mudler#186 was measured against `93852c28` in isolation, so none of their numbers described a tree carrying all three. STATUS.md was deliberately left at the CONSERVATIVE 4.24 -- the largest single lever -- rather than a sum, because adding independently-measured deltas would have been inventing a number. This is the measurement that replaces it. METHOD. `git archive` of merged main `81ea01f0` to dgx, `vt_matmul.comp` md5 verified identical on both sides. Fresh Release configure. 8 wall-clock legs with the page cache dropped before each and `flock $HOME/gpu.lock` held, then a two-length GPU-timestamp diff (output-len 36 minus 4, over 32 decode tokens) so prefill and one-time costs cancel. Qwen3.6-27B bf16, 1 prompt, 32-in, c1. MEASURED, 8 legs, ALL CLEAN: TPOT 232.18 to 234.07 ms, decode 4.27 to 4.31 tok/s, MEDIAN 4.285, spread 0.8%. Zero bimodal collapses. MEASURED, per decode token: shader before merged delta vt_matmul_vec 214.1 210.1 -4.0 vt_matmul (lm_head) 12.43 11.57 -0.86 vt_rms_norm -> vt_rms_norm_wide 7.97 1.57 -6.40 TOTAL GPU 240.3 227.7 -12.6 Wall 233.0 ms, so host is 5.3 ms/token. Every lever reproduced its own claim on the merged tree: vt_rms_norm_wide at 0.0123 ms/call is exactly what its row reported, and the other two deltas match theirs inside the leg spread. A FIRST ATTEMPT WAS INVALID AND IS RECORDED, because the failure is reusable. It read 0.75-1.27 tok/s. VLLM_CPP_VULKAN defaults to AUTO, which resolves to OFF -- Vulkan is opt-in so it cannot register into gate builds -- and the fresh configure omitted -DVLLM_CPP_VULKAN=ON. The options had been copied from the reference build's CMakeCache.txt through `grep | head -15`, and the alphabetical list ended at TRITON_TARGET, exactly one line before VULKAN. My own truncation hid the flag. Three tells were already in the output and are the cheap check: no `[vt vulkan]` lines despite VT_VULKAN_DISPATCH_STATS=1, no `[vt reference-tier]` lines, and "Asynchronous scheduling is ENABLED" where every valid Vulkan run reports it disabled. Same family as this campaign's stale-binary false greens, in the opposite direction -- a false catastrophe rather than a false pass. A CORRECTION TO THIS CAMPAIGN'S ROOF ARITHMETIC. mudler#186 established that GB10 does not reach its theoretical 273 GB/s, by running a known-good streaming kernel on the identical byte count: 230.3 GB/s. That correctly retires the 9.3 ms lm_head floor. It does NOT invalidate the layer-GEMV percentages, which MEASURE 243-248 GB/s -- above 230.3 -- so 230.3 is a ceiling for THAT SHAPE (k=5120, n=248320, one 2.54 GB buffer), not a device ceiling. Why one shape's ceiling sits ~7% below the same kernel's on layer weights is UNEXPLAINED, and it is the same lone buffer the 20x bimodal collapse attaches to. WHERE THE REMAINING 3.1 ms IS. llama.cpp Vulkan is 4.35 tok/s = 229.9 ms/token on the same 50.89 GiB weights on this box. vt_matmul_vec is now 92% of our GPU time at 248.0 GB/s, and its lever is CLOSED with the binding constraint identified as DRAM bandwidth on a ~50 GB working set streamed once per token. The named residuals are lm_head (219.8 GB/s, 95.4% of its own shape's measured ceiling, worth ~1.3 ms if it could reach 248) and host at 5.3 ms. Also refreshes .agents/NOW.md in the same change and appends the dated checkpoint below the enforced marker in .agents/state.md, per the handoff contract. KNOWN RED, unchanged and pre-existing: docs/STATUS.md remains over its shrink-only ratchet (this change shrinks it by 3 more chars). FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Claude-Code:claude-opus-5 [Claude Code]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
vt_matmul_vecis 89-90% of 27B decode GPU time and moves ~52 GB of bf16weights per token, so it was the largest single line item left. Two structural
differences against llama.cpp's
mul_mat_vec(pin237ad9b96) were thecandidates. One is worth about 1%, the other is a measured LOSS on this
device, and the win is 7x smaller than the isolated sweep first said. All
three of those are the result.
What shipped
VT_MM_PACK, a specialization constant selecting the LOAD WIDTH for a 16-bitoperand: one element per load (2 B, the previous behaviour), two through the
buffer's existing 32-bit view, or four through a new 64-bit view of the same
VkBuffer. Ported in intent from llama.cpp'sdata_b_v4/dequantize4_2aligned. Same bytes, same coalescing, so this cannot reduceDRAM traffic; it reduces load instructions, four to one. Default 2, degrading a
width at a time when K or an operand byte offset is not aligned, and declining
for an f32 operand.
What did NOT work, with its number
VT_MM_ROWS, llama.cpp'sNUM_ROWS(it sets this per vendor viarm_stdq),computing 2 or 4 output elements per workgroup. Measured 0.966x at 2 rows and
0.968x at 4 — a loss in every pass at every load width. It ships OFF. What it
was supposed to buy was never traffic (weights are read exactly once per token at
any row count) and the activation re-reads it saves were L2 hits; halving the
workgroup count halves the independent sequential read streams the memory
controller sees, which is the likely cost. The axis is kept, off by default,
because llama.cpp raises it exactly on the AMD GCN and Intel parts this backend's
portability case is aimed at. It is bit-identical to rows=1 at any value,
verified by full-output bit-hash rather than by argument.
Measured
Isolated sweep (
benchmarks/vulkan_gemv_ab.cpp, 7 real 27B shapes, 9 arms x 4rotated passes, each arm paired against the baseline in the same pass because
the box drifted 15.5% peak-to-peak between passes):
Real 27B decode, the number of record — two-length GPU-timestamp diff (len 36
minus len 4, over 32 tokens) so prefill and one-time costs cancel, run twice with
arm order reversed:
vt_matmul_vecms1.012x and 1.020x, not 1.086x. The sweep was in the wrong regime: it re-reads
one 356 MB buffer 320 times, so its DRAM rows and TLB stay hot and instruction
issue becomes visible; decode streams 50 GB of distinct weights once per token,
where DRAM is the whole story. The campaign already has "a negative result is
regime-dependent" on record; this is the same lesson running the other way — a
win can be a regime artefact too.
e2e AB/BA: 14 pairs across two blocks; 6 pairs had both legs clean,
pack=2won5 of 6, median paired 1.0064x, clean-leg medians 242.38 -> 240.54 ms =
4.126 -> 4.157 tok/s. Measured noise floor on clean legs 0.88% (baseline arm)
and 1.92% (pack arm), so 0.7% is at the edge of e2e resolvability and it is the
GPU-timestamp diff that resolves it. Stated plainly rather than dressed up.
The GEMV lever is essentially empty and now we know why. The binding
constraint is DRAM bandwidth on a ~50 GB working set streamed once per token —
not instruction issue, not latency, not activation re-reads.
The finding worth more than the 1%
The ~1.8x bimodal decode legs this campaign has repeatedly discarded as
environmental are
vt_matmul— the lm_head — collapsing 19.5x: 12.35 ->242.12 ms/token, i.e. 205 GB/s -> 10.5 GB/s on 2.54 GB of weights.
vt_matmul_vecbeside it moved 1.2% in the same legs; TTFT is unchanged acrossthe modes (6.21-6.55 s over all 28 legs), so it is neither load nor prefill. It
hit 8 of 28 legs, arm-symmetric. This is one kernel entering a slow state, not
the environment, and it is a bigger lever than every remaining Vulkan kernel
optimisation combined. Being chased separately.
Gates, verified by the operator rather than taken on report
Re-run independently on a clean llvmpipe build:
test_vulkan_backend30/30, 1823 assertions (was 29/1786; the new case and37 assertions are the variant-selection test)
test_backend_cross_device11/11, 132 assertionsVLLM_CPP_DEVICE=vulkan test_opt_paged_engine6/6 prompts token-exact(96/96 tokens), 0 declines
gen-vulkan-spirv.py --checkunder pinned glslang 16.5.0: committed SPIR-V isup to date
Agent-reported and not re-run by me: GB10
test_vulkan_backend30/30 with 2366assertions; all 9 (rows, pack) arms verified numerically; scratch mutation
(reverting
kGemvPackDefaultto 0) turns the selection test red 6/33 and greenon restore.
Known-red gates, named rather than papered over
check-pr-sizecounts the regeneratedvulkan_spirv.cppas reviewable productcode — the same checker gap every Vulkan shader PR hits.
check-public-doc-tablesstill fails on the
docs/STATUS.mdratchet, which main already violates and thisbranch leaves byte-neutral. The other preflight failures (undocumented
VT_GEMMA4_*/VT_ROCM_*vars, trailer commit39da53c7) are pre-existing on thebase.
FOLLOWING_AGENTS_PROTOCOL